Skip to content

fix(cliffs): rotbb collision boxes are ROTATED, not their bounding box (#84) - #88

Merged
wormeyman merged 1 commit into
mainfrom
fix/cliff-oriented-collision-box
Aug 2, 2026
Merged

fix(cliffs): rotbb collision boxes are ROTATED, not their bounding box (#84)#88
wormeyman merged 1 commit into
mainfrom
fix/cliff-oriented-collision-box

Conversation

@wormeyman

Copy link
Copy Markdown
Owner

The lava perimeter that cost 13 real Vulcanus cliffs their placement was never the lava mask. It was the collision box's shape.

The bug

rotbb(x, y, size, intersect) (entity-util.lua:9) returns {{cx-x_dist, cy-y_dist}, {cx+x_dist, cy+y_dist}, 1/8} — a rectangle plus an orientation of 1/8, i.e. 45°. Sixteen of the twenty cliff orientations are built with it; only the four straight ones are axis-aligned. The port kept only the bounding box, which overruns the rotated rectangle at all four corners — so it rejected cliffs on lava the game never looks at.

CLIFF_ORIENTATION_COLLISION_BOX remains the correct broad phase (wouldCollide derives its tile rect from a fixed-point floor over an inclusive rectangle). cliffBoxCoversTile adds the narrow phase.

AABB (before) oriented (after)
recall 0.9675 0.9758
precision 0.9743 0.9727
[0,0] recall 0.9788 1.0000
level-sweep recall 0.951 at level 20 ~1.000 at every level

Clears 13 of 13 false rejections while keeping 182 of the 185 rejections that remove genuine false positives — the correct shape, not a loosening that trades precision for recall.

How it was found

The standing explanation — in the spec's own comments and in the notes — was that the tile resolver was "off by about one tile SOMEWHERE". A new dense capture (oracle-vulcanus-lava-boundary, radius-4 neighbourhoods around the 35 accusing tiles) found zero lava mismatches over 994 positions, and 35/35 agreement at the accusing tiles themselves. The mask was innocent.

That capture was worth making because the existing 381-position survey structurally could not answer the question: its sensitivity was measured by planting scale factors on lava's probability, and 1.02 and 1.2 both still pass.

Two corrections this forces

  • PR test(cliffs): the "excess" was the lava rejection, not a second defect (#84) #86's "gap 0.067 → 0.018" is wrong; it is 0.024. That was measured with the over-aggressive AABB rejection, which deleted cells the game keeps and so flattered exactly the ratio it reported. A too-strong correction hides the thing it is correcting. The residual below elevation 120 is real, still open, and is now pure over-placement with no recall cost.
  • "All 13 sit at Chebyshev depth 1 in our lava" was a true measurement that pointed at the wrong suspect. The box's four corners are its perimeter, so a corner-shaped box error produces the same signature a one-tile-fat mask would. Two mechanisms, one fingerprint. A statistic can only rule a suspect out if it would come out differently for each candidate.

Checked and cleared on the way

  • The inclusive-floor fringe is real engine behaviour, not our bug — (box + position) >> 8 over an inclusive rect, already established by disassembly.
  • Chunk ordering is not it: 2 of 13 cross a chunk boundary against 50 of 185 in the control — less than baseline.
  • 6 of the 13 also carry a wrong orientation (~23x enrichment over the 2.0% base rate), always a smaller -to-none variant of ours. A wrong orientation means the wrong box, so the two defects compound; those 6 belong to the standing orientation residual, which is unchanged.

Guards

cliffOrientedBox.spec.ts pins the geometry; the levels sweep asserts per-level recall > 0.99. Both verified non-vacuous by planting AABB behaviour — they fail with expected 0 to be greater than 20 and expected 0.9514 to be greater than 0.99.

pnpm run verify green: 1314 passed / 3 skipped, 0 warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

#84)

The lava perimeter that cost 13 real Vulcanus cliffs their placement was never
the lava mask. It was the collision box's shape.

rotbb(x, y, size, intersect) (entity-util.lua:9) returns
{{cx-x_dist, cy-y_dist}, {cx+x_dist, cy+y_dist}, 1/8} - a rectangle PLUS an
orientation of 1/8, i.e. 45 degrees. Sixteen of the twenty cliff orientations are
built with it; only the four straight ones are axis-aligned. The port kept only
the bounding box, which overruns the rotated rectangle at all four corners, so it
rejected cliffs on lava the game never looks at.

CLIFF_ORIENTATION_COLLISION_BOX remains the correct BROAD phase - wouldCollide
derives its tile rect from a fixed-point floor over an inclusive rectangle, which
cliffCollisionTileBox reproduces. cliffBoxCoversTile adds the narrow phase
(separating-axis over the two world axes and the rectangle's own two).

|                     | AABB (before) | oriented (after) |
| ------------------- | ------------- | ---------------- |
| recall              | 0.9675        | 0.9758           |
| precision           | 0.9743        | 0.9727           |
| [0,0] recall        | 0.9788        | 1.0000           |
| level-sweep recall  | 0.951 @ lvl20 | ~1.000 everywhere |

Clears 13 of 13 false rejections while keeping 182 of the 185 rejections that
remove genuine false positives - the correct shape, not a loosening that trades
precision for recall.

How it was found, because the route matters more than the fix. The standing
explanation - in this spec's own comments and in the notes - was that the tile
resolver was "off by about one tile SOMEWHERE". A new dense capture
(oracle-vulcanus-lava-boundary, radius-4 neighbourhoods around the 35 accusing
tiles) found ZERO lava mismatches over 994 positions and 35/35 at the accusing
tiles themselves. The mask was innocent. It was worth capturing because the
existing 381-position survey structurally could not answer the question: its
sensitivity was measured by planting scale factors on lava's probability, and
1.02 and 1.2 both still pass.

Two corrections this forces:

- PR #86's "gap 0.067 -> 0.018" is wrong; it is 0.024. That was measured with the
  over-aggressive AABB rejection, which deleted cells the game keeps and so
  flattered exactly the ratio it reported. A too-strong correction hides the
  thing it is correcting. The residual below elevation 120 is real, still open,
  and now pure over-placement with no recall cost.
- "All 13 sit at Chebyshev depth 1 in our lava" was a true measurement that
  pointed at the wrong suspect. The box's four corners ARE its perimeter, so a
  corner-shaped box error produces the same signature a one-tile-fat mask would.
  A statistic can only rule a suspect out if it would come out DIFFERENTLY for
  each candidate.

Checked and cleared on the way: the inclusive-floor fringe is real engine
behaviour (disassembly), and chunk ordering is not it (2 of 13 cross a chunk
boundary against 50 of 185 in the control - less than baseline). 6 of the 13 also
carry a wrong orientation (~23x enrichment over the 2.0% base rate), always a
smaller -to-none variant of ours; those belong to the standing orientation
residual, which is unchanged.

Guards: cliffOrientedBox.spec.ts pins the geometry, and the levels sweep asserts
per-level recall > 0.99. Both verified non-vacuous by planting AABB behaviour -
they fail with "expected 0 to be greater than 20" and "expected 0.9514 to be
greater than 0.99".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt
@wormeyman
wormeyman merged commit 8243b65 into main Aug 2, 2026
2 checks passed
wormeyman added a commit that referenced this pull request Aug 2, 2026
)

Disassembly, prompted by flagging #88's fix as the thing I was least confident
about. It was right to flag: #88 scored best on every metric and was wrong about
the mechanism.

The engine does NOT collide against rotbb's rectangle rotated 45 degrees. It uses
the RAW stored rectangle and discards the orientation tag entirely:

1. EntityMapGenerationTask::tryToAddCliff (0x101625038) loads the orientation's
   box from proto + 0x5c0 + id*0x48 (20 bytes: four int32 edges at +4, the
   orientation word at +0x14) and calls wouldCollide with Direction = 0 -
   literally `mov x4, #0x0`.
2. wouldCollide (0x101625468) forwards box and direction to
   BoundingBox::BoundingBox(BoundingBox const&, Direction) (0x101c04380), then
   floors with (box + position) >> 8 over an inclusive tile rect.
3. That constructor zeroes the destination, writes sentinel 0x80010000 into the
   destination's orientation word, and dispatches through a jump table whose
   entry 0 is 0 (read at 0x102d01400) - the identity arm, which copies
   left_top/right_bottom verbatim. The source orientation is never read; the
   rotate arm is reachable only for a non-zero Direction.

Corroborated by the API mirror: BoundingBox is documented as {MapPosition,
MapPosition} or {..., RealOrientation} with orientation OPTIONAL, and
OrientedCliffPrototype::collision_bounding_box is a plain BoundingBox.

| box                            | false rej | recall | precision | evidence   |
| ------------------------------ | --------- | ------ | --------- | ---------- |
| AABB (until #88)               | 13        | 0.9675 | 0.9743    | assumption |
| 45-degree oriented rect (#88)  | 0         | 0.9758 | 0.9727    | fit only   |
| raw stored rect (this)         | 6         | 0.9720 | 0.9713    | disasm     |

The middle row is the trap. It shrank the box past the engine's, and the excess
shrinkage absorbed a DIFFERENT defect: 4 of the 6 cliffs the correct box still
rejects are cells where our orientation disagrees with the game's, so we load the
wrong box entirely. Those 4 belong to the standing orientation residual and
should stay visible. A model that scores perfectly by hiding a second bug is
worse than one that leaves it exposed.

Edges are quantised to 1/256 - MapPosition is 8-bit fixed point, so x_dist's
sqrt(2) cannot reach the engine at full precision.

test/cliffOrientation.spec.ts asserted the OPPOSITE of this until now, on the
stated reasoning that the AABB "is what the engine ends up scanning". That block
is rewritten: the shipped box is the raw rectangle, it DEPENDS on intersect (the
old file asserted independence), and its area is at most half the AABB's - though
it is not contained in it, since a small intersect pushes hx past size/2. An
attempt to assert containment on every axis failed for exactly that reason.

cliffBoxCoversTile and the separating-axis narrow phase are deleted; the broad
phase alone is now the whole rule, which is what the engine does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt
wormeyman added a commit that referenced this pull request Aug 2, 2026
) (#90)

* docs(cliffs): shipping orientation is 2.4%, not 2.0% (#84)

The banner kept the pre-#88 figure. Since the collision box was corrected to
rotbb's rotated rectangle the rejection costs ZERO true positives, so the
shipping matched set is identical to the unfiltered one - 1531 either way, 37
wrong orientations, 2.42%. Measured on both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

* fix(cliffs): the collision box is the RAW rectangle - #88 was wrong (#84)

Disassembly, prompted by flagging #88's fix as the thing I was least confident
about. It was right to flag: #88 scored best on every metric and was wrong about
the mechanism.

The engine does NOT collide against rotbb's rectangle rotated 45 degrees. It uses
the RAW stored rectangle and discards the orientation tag entirely:

1. EntityMapGenerationTask::tryToAddCliff (0x101625038) loads the orientation's
   box from proto + 0x5c0 + id*0x48 (20 bytes: four int32 edges at +4, the
   orientation word at +0x14) and calls wouldCollide with Direction = 0 -
   literally `mov x4, #0x0`.
2. wouldCollide (0x101625468) forwards box and direction to
   BoundingBox::BoundingBox(BoundingBox const&, Direction) (0x101c04380), then
   floors with (box + position) >> 8 over an inclusive tile rect.
3. That constructor zeroes the destination, writes sentinel 0x80010000 into the
   destination's orientation word, and dispatches through a jump table whose
   entry 0 is 0 (read at 0x102d01400) - the identity arm, which copies
   left_top/right_bottom verbatim. The source orientation is never read; the
   rotate arm is reachable only for a non-zero Direction.

Corroborated by the API mirror: BoundingBox is documented as {MapPosition,
MapPosition} or {..., RealOrientation} with orientation OPTIONAL, and
OrientedCliffPrototype::collision_bounding_box is a plain BoundingBox.

| box                            | false rej | recall | precision | evidence   |
| ------------------------------ | --------- | ------ | --------- | ---------- |
| AABB (until #88)               | 13        | 0.9675 | 0.9743    | assumption |
| 45-degree oriented rect (#88)  | 0         | 0.9758 | 0.9727    | fit only   |
| raw stored rect (this)         | 6         | 0.9720 | 0.9713    | disasm     |

The middle row is the trap. It shrank the box past the engine's, and the excess
shrinkage absorbed a DIFFERENT defect: 4 of the 6 cliffs the correct box still
rejects are cells where our orientation disagrees with the game's, so we load the
wrong box entirely. Those 4 belong to the standing orientation residual and
should stay visible. A model that scores perfectly by hiding a second bug is
worse than one that leaves it exposed.

Edges are quantised to 1/256 - MapPosition is 8-bit fixed point, so x_dist's
sqrt(2) cannot reach the engine at full precision.

test/cliffOrientation.spec.ts asserted the OPPOSITE of this until now, on the
stated reasoning that the AABB "is what the engine ends up scanning". That block
is rewritten: the shipped box is the raw rectangle, it DEPENDS on intersect (the
old file asserted independence), and its area is at most half the AABB's - though
it is not contained in it, since a small intersect pushes hx past size/2. An
attempt to assert containment on every axis failed for exactly that reason.

cliffBoxCoversTile and the separating-axis narrow phase are deleted; the broad
phase alone is now the whole rule, which is what the engine does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 2, 2026
…#84) (#93)

#91 handed over one lead: "the defect is in the grid-4 cliff-elevation field,
the one input with no per-corner oracle". It is refuted, and so is the idea
that there is a single cause.

Everything upstream of the crossing test is now measured, not read:

- grid-1 `vulcanus_elevation` exact to 4.8e-2 over all 12,675 captured corners
- the grid-4 `multisample` min-filter reproduces the game through the CLIFF
  GENERATOR at both [0,0] and [1500,1500]; grid 1/2/8/centred all score far
  worse, and `multisample(e,0,0)` is the identity in this channel
- `cliffiness_basic` exact to 6.4e-6 - over the 4,266 UNCLAMPED corners.
  8,409 of 12,675 sit ON a clamp, so the old "exact" claim was two-thirds
  vacuous: it was measuring the clamp, not `qmn`
- `cliff_smoothing = 1` READ BACK off Vulcanus's own surface. It had been
  inferred from the prototype default since #28 and never once measured
- the smoothing stencil measured on both axes with a delta probe, whose
  in-chunk-3 arms predict the game places NOTHING and it places nothing
- `crossingsForChunk` re-disassembled at 0x10160c9cc (the VA in the notes had
  moved); `smoothingKnots` matches it line for line
- `fixImpossibleCells`'s give-up branch never fires: 0 chunks need a retry

With `cliff_smoothing = 0` and every other term real:

  [0,0]        0 wrong   (7 at s=1)
  [-1200,800]  0 wrong   (4 at s=1), precision 1.0000
  [1500,1500]  21 wrong  (26 at s=1)

So two regions carry a smoothing-only defect and one carries a defect that
survives smoothing being off - all over-detections, all at the high bands
(670/790/1030), margins 0.69-46.6 units, so not float32. Scoring [0,0] alone
says "it's the smoothing", which is false for 21 of the 37; two regions out of
three agreeing is the same trap as #88.

Also re-scopes #84 item 2: the "over-placement below elevation 120" is not
spread over the low band, it is one contiguous blob (cells cx 43-48, cy 34-40)
where the game places zero cliffs under every setting tried AND under a
synthetic cliff_elevation. A field-independent hole is a rule we do not
implement, not a field error - and it is not lava.

Two new oracle fixtures + provenance; no src change.


Claude-Session: https://claude.ai/code/session_015yqZhJCUVLDU5q7SVwAVkj

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 2, 2026
#99 characterised the rule and stopped short of porting it, flagging one open
sub-question: whether driving it from our own resource model, rather than the
game's entities, is accurate enough. It is - and it costs exactly one cell.

Scored across all three oracle regions, driving `makeVulcanusOreRejection` off
the same field stack the ore overlay paints from:

| region | game | placed | fires | false rejections | surplus |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 | 0 | 2 -> 2 |
| [1500,1500] | 885 | 900 | 20 | 0 | 42 -> 22 |
| [-1200,800] | 401 | 387 | 0 | 0 | 1 -> 1 |

Precision at [1500,1500] 0.953 -> 0.975 with the 858 true positives untouched.
**Recall is not touched anywhere**, which was the gate: this rule may only ever
cost precision.

Three variants were scored and the two that lose are kept in the spec rather
than dismissed in a comment, because #88/#90 already paid for that lesson here:

| variant | fires | correct of 31 | false rejections |
| --- | --- | --- | --- |
| base box, ores only (SHIPPED) | 20 | 20 | 0 |
| base box + geyser | 21 | 20 | 1 |
| per-orientation box | 23 | 21 | 2 |

The geyser arm is strictly HARMFUL - one more false rejection and not one
additional correct suppression - so it is implemented behind `includeGeyser`,
defaulting off. The per-orientation rotbb box catches one more true cell and
pays two kept cliffs for it; higher `correct` is exactly the trap.

Not claimed: the mechanism is still open (the disassembly says cliffs are placed
before any resource entity exists), and 11 of the 31 stay unexplained with the
box deliberately not widened to cover them. The spec pins that 11.

Lands as `CliffBands.cellRejects`, an opaque per-cell predicate beside
`tileCollides` - so the shared cliff core stays planet-agnostic, and the model
the specs score is the model the renderer ships. The predicate enumerates no
entities: the overlapping tiles follow in closed form (2 tiles for an ore
against the lava rejection's ~30), guarded by a wider brute-force scan.


Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
…0.9961 (#84) (#101)

* feat(cliffs): port the ORE -> CLIFF rejection (#84 item 1)

#99 characterised the rule and stopped short of porting it, flagging one open
sub-question: whether driving it from our own resource model, rather than the
game's entities, is accurate enough. It is - and it costs exactly one cell.

Scored across all three oracle regions, driving `makeVulcanusOreRejection` off
the same field stack the ore overlay paints from:

| region | game | placed | fires | false rejections | surplus |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 | 0 | 2 -> 2 |
| [1500,1500] | 885 | 900 | 20 | 0 | 42 -> 22 |
| [-1200,800] | 401 | 387 | 0 | 0 | 1 -> 1 |

Precision at [1500,1500] 0.953 -> 0.975 with the 858 true positives untouched.
**Recall is not touched anywhere**, which was the gate: this rule may only ever
cost precision.

Three variants were scored and the two that lose are kept in the spec rather
than dismissed in a comment, because #88/#90 already paid for that lesson here:

| variant | fires | correct of 31 | false rejections |
| --- | --- | --- | --- |
| base box, ores only (SHIPPED) | 20 | 20 | 0 |
| base box + geyser | 21 | 20 | 1 |
| per-orientation box | 23 | 21 | 2 |

The geyser arm is strictly HARMFUL - one more false rejection and not one
additional correct suppression - so it is implemented behind `includeGeyser`,
defaulting off. The per-orientation rotbb box catches one more true cell and
pays two kept cliffs for it; higher `correct` is exactly the trap.

Not claimed: the mechanism is still open (the disassembly says cliffs are placed
before any resource entity exists), and 11 of the 31 stay unexplained with the
box deliberately not widened to cover them. The spec pins that 11.

Lands as `CliffBands.cellRejects`, an opaque per-cell predicate beside
`tileCollides` - so the shared cliff core stays planet-agnostic, and the model
the specs score is the model the renderer ships. The predicate enumerates no
entities: the overlapping tiles follow in closed form (2 tiles for an ore
against the lava rejection's ~30), guarded by a wider brute-force scan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the budget FLIPPED - recall is now the bigger defect (#84)

Every cliff defect found since #18 has been a rule the port over-places without
(lava collision, the rotbb box shape, the ore suppression), so "find another
rejection" has been the shape of the work throughout. After #100 that is no
longer where the error is.

| region | surplus | missing | lava-killed | ore-killed | never generated |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 2 | 2 | 2 | 0 | 0 |
| [1500,1500] | 22 | 27 | 3 | 0 | 24 |
| [-1200,800] | 1 | 15 | 1 | 0 | 14 |
| total | 25 | 44 | 6 | 0 | 38 |

**The port now misses more cells than it over-places, 44 to 25**, and 38 of the
44 are cells the crossings stage never produces at all - a different defect in a
different part of the port from everything solved so far.

`[0,0]` generates every cell the game does: its whole miss is the two the lava
rejection took, and `neverGenerated` is zero there. All 38 sit in the two
far-field regions, which agrees with #93 finding the port exact at [0,0] and
[-1200,800] at cliff_smoothing = 0 and still wrong at [1500,1500].

Also closes item 3 (the entity half of `Surface::wouldCollide`) UNPORTED, by
size rather than by difficulty - the same move that retired fixImpossibleCells
as a suspect. It is a rejection, and rejections can only remove cells: total
surplus is 25, which bounds what rocks and craters together could ever be worth
against a 44-cell recall gap they cannot touch.

The crater arm is settled exactly, since craters are already in the fixtures:
all 8 sit in [-1200,800] and not one touches a cell the port over-places, nor
any cliff the game kept. Worth zero. The rock arm needs no fixture - the ceiling
covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the recall gap was a QUERY-WINDOW ARTIFACT - recall is 0.9961 (#84)

Supersedes this branch's first commit, whose central claim was wrong.

`find_entities_filtered` selects entities whose BOUNDING BOX touches the query
area; `placedCells` emits cells whose CENTRE lies inside it. Different inclusion
rules, so the fixtures carry cliffs centred just outside the box and every one
has been scored as a miss.

| region | game rows | centred inside | centred OUTSIDE |
| --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 |
| [1500,1500] | 885 | 861 | 24 |
| [-1200,800] | 401 | 387 | 14 |

That is 38 cells - the entire apparent recall gap - and the port places 38 of 38
once the query box includes their centres. Every one is an agreement being
scored as a failure. The widening arm is the load-bearing one: "we never looked
there" alone is equally consistent with the port being wrong.

Corrected budget, both sides scored alike:

| region | game | port | matched | surplus | missing |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 281 | 2 | 2 |
| [1500,1500] | 861 | 880 | 858 | 22 | 3 |
| [-1200,800] | 387 | 387 | 386 | 1 | 1 |
| total | 1531 | 1550 | 1525 | 25 | 6 |

**Recall 0.9961, precision 0.9839.** The 0.972 recall in the notes divided the
same 1525 matches by 1569 rather than 1531 - the match count was never wrong,
only the denominator.

All 6 missing cells are ones our own lava rejection removed; there is no cell
the port simply fails to generate. So precision is the only real defect left.

Consequently item 3 (the entity half of Surface::wouldCollide) is RE-OPENED.
The earlier commit closed it by size, arguing a rejection cannot help a 44-cell
recall gap - that argument died with the gap. With recall at 0.9961 the dominant
defect is the 25 surplus cells, which is exactly what a rejection removes.

The crater arm stays settled at zero (all 8 sit in [-1200,800], none touches a
surplus cell). The rock arm has no oracle capture at all, so capturing one is
the next step - now with a 25-cell target rather than a ceiling against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the rock arm fails on the mechanism's own geometry (#84)

Completes the item 3 thread in this branch rather than opening a second PR for
it, since the previous commit had just re-opened it.

No rock capture is needed to kill it. `computeInternal` runs `generateCliffs`
before `generateEntities`, and `apply` runs `applyCliffs` (+124) before
`applyEntities` (+164), so within a chunk no rock exists when the cliff is
applied. A rock can only block a cliff from an ALREADY-GENERATED NEIGHBOUR,
which confines the whole mechanism to cells near a 32-tile chunk border.

| | n | near chunk border |
| --- | --- | --- |
| surplus | 25 | 11 = 44.0% |
| matched | 1525 | 673 = 44.1% |

The base rate to three significant figures. The surplus has no chunk-border
character at all, so the one geometry the mechanism is confined to is not where
the errors are.

The direct overlap test agrees and is deliberately the weaker arm: 3 of 25
surplus cells overlap a modelled rock against a 6.6% base rate (~1.7 expected),
which is nothing - and our rock placement is a salt-dependent roll whose
individual positions are unreliable exactly as the geyser's were in #100.

So item 3 explains approximately none of the 25, and is closed on the
mechanism's geometry rather than on the ceiling argument that died with the
recall gap. Remaining unexplained: 25 surplus, 6 missing (all lava-rejection
over-rejections), 33 wrong orientations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
)

* feat(cliffs): port the ORE -> CLIFF rejection (#84 item 1)

#99 characterised the rule and stopped short of porting it, flagging one open
sub-question: whether driving it from our own resource model, rather than the
game's entities, is accurate enough. It is - and it costs exactly one cell.

Scored across all three oracle regions, driving `makeVulcanusOreRejection` off
the same field stack the ore overlay paints from:

| region | game | placed | fires | false rejections | surplus |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 | 0 | 2 -> 2 |
| [1500,1500] | 885 | 900 | 20 | 0 | 42 -> 22 |
| [-1200,800] | 401 | 387 | 0 | 0 | 1 -> 1 |

Precision at [1500,1500] 0.953 -> 0.975 with the 858 true positives untouched.
**Recall is not touched anywhere**, which was the gate: this rule may only ever
cost precision.

Three variants were scored and the two that lose are kept in the spec rather
than dismissed in a comment, because #88/#90 already paid for that lesson here:

| variant | fires | correct of 31 | false rejections |
| --- | --- | --- | --- |
| base box, ores only (SHIPPED) | 20 | 20 | 0 |
| base box + geyser | 21 | 20 | 1 |
| per-orientation box | 23 | 21 | 2 |

The geyser arm is strictly HARMFUL - one more false rejection and not one
additional correct suppression - so it is implemented behind `includeGeyser`,
defaulting off. The per-orientation rotbb box catches one more true cell and
pays two kept cliffs for it; higher `correct` is exactly the trap.

Not claimed: the mechanism is still open (the disassembly says cliffs are placed
before any resource entity exists), and 11 of the 31 stay unexplained with the
box deliberately not widened to cover them. The spec pins that 11.

Lands as `CliffBands.cellRejects`, an opaque per-cell predicate beside
`tileCollides` - so the shared cliff core stays planet-agnostic, and the model
the specs score is the model the renderer ships. The predicate enumerates no
entities: the overlapping tiles follow in closed form (2 tiles for an ore
against the lava rejection's ~30), guarded by a wider brute-force scan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the budget FLIPPED - recall is now the bigger defect (#84)

Every cliff defect found since #18 has been a rule the port over-places without
(lava collision, the rotbb box shape, the ore suppression), so "find another
rejection" has been the shape of the work throughout. After #100 that is no
longer where the error is.

| region | surplus | missing | lava-killed | ore-killed | never generated |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 2 | 2 | 2 | 0 | 0 |
| [1500,1500] | 22 | 27 | 3 | 0 | 24 |
| [-1200,800] | 1 | 15 | 1 | 0 | 14 |
| total | 25 | 44 | 6 | 0 | 38 |

**The port now misses more cells than it over-places, 44 to 25**, and 38 of the
44 are cells the crossings stage never produces at all - a different defect in a
different part of the port from everything solved so far.

`[0,0]` generates every cell the game does: its whole miss is the two the lava
rejection took, and `neverGenerated` is zero there. All 38 sit in the two
far-field regions, which agrees with #93 finding the port exact at [0,0] and
[-1200,800] at cliff_smoothing = 0 and still wrong at [1500,1500].

Also closes item 3 (the entity half of `Surface::wouldCollide`) UNPORTED, by
size rather than by difficulty - the same move that retired fixImpossibleCells
as a suspect. It is a rejection, and rejections can only remove cells: total
surplus is 25, which bounds what rocks and craters together could ever be worth
against a 44-cell recall gap they cannot touch.

The crater arm is settled exactly, since craters are already in the fixtures:
all 8 sit in [-1200,800] and not one touches a cell the port over-places, nor
any cliff the game kept. Worth zero. The rock arm needs no fixture - the ceiling
covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the recall gap was a QUERY-WINDOW ARTIFACT - recall is 0.9961 (#84)

Supersedes this branch's first commit, whose central claim was wrong.

`find_entities_filtered` selects entities whose BOUNDING BOX touches the query
area; `placedCells` emits cells whose CENTRE lies inside it. Different inclusion
rules, so the fixtures carry cliffs centred just outside the box and every one
has been scored as a miss.

| region | game rows | centred inside | centred OUTSIDE |
| --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 |
| [1500,1500] | 885 | 861 | 24 |
| [-1200,800] | 401 | 387 | 14 |

That is 38 cells - the entire apparent recall gap - and the port places 38 of 38
once the query box includes their centres. Every one is an agreement being
scored as a failure. The widening arm is the load-bearing one: "we never looked
there" alone is equally consistent with the port being wrong.

Corrected budget, both sides scored alike:

| region | game | port | matched | surplus | missing |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 281 | 2 | 2 |
| [1500,1500] | 861 | 880 | 858 | 22 | 3 |
| [-1200,800] | 387 | 387 | 386 | 1 | 1 |
| total | 1531 | 1550 | 1525 | 25 | 6 |

**Recall 0.9961, precision 0.9839.** The 0.972 recall in the notes divided the
same 1525 matches by 1569 rather than 1531 - the match count was never wrong,
only the denominator.

All 6 missing cells are ones our own lava rejection removed; there is no cell
the port simply fails to generate. So precision is the only real defect left.

Consequently item 3 (the entity half of Surface::wouldCollide) is RE-OPENED.
The earlier commit closed it by size, arguing a rejection cannot help a 44-cell
recall gap - that argument died with the gap. With recall at 0.9961 the dominant
defect is the 25 surplus cells, which is exactly what a rejection removes.

The crater arm stays settled at zero (all 8 sit in [-1200,800], none touches a
surplus cell). The rock arm has no oracle capture at all, so capturing one is
the next step - now with a 25-cell target rather than a ceiling against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the rock arm fails on the mechanism's own geometry (#84)

Completes the item 3 thread in this branch rather than opening a second PR for
it, since the previous commit had just re-opened it.

No rock capture is needed to kill it. `computeInternal` runs `generateCliffs`
before `generateEntities`, and `apply` runs `applyCliffs` (+124) before
`applyEntities` (+164), so within a chunk no rock exists when the cliff is
applied. A rock can only block a cliff from an ALREADY-GENERATED NEIGHBOUR,
which confines the whole mechanism to cells near a 32-tile chunk border.

| | n | near chunk border |
| --- | --- | --- |
| surplus | 25 | 11 = 44.0% |
| matched | 1525 | 673 = 44.1% |

The base rate to three significant figures. The surplus has no chunk-border
character at all, so the one geometry the mechanism is confined to is not where
the errors are.

The direct overlap test agrees and is deliberately the weaker arm: 3 of 25
surplus cells overlap a modelled rock against a 6.6% base rate (~1.7 expected),
which is nothing - and our rock placement is a salt-dependent roll whose
individual positions are unreliable exactly as the geyser's were in #100.

So item 3 explains approximately none of the 25, and is closed on the
mechanism's geometry rather than on the ceiling argument that died with the
recall gap. Remaining unexplained: 25 surplus, 6 missing (all lava-rejection
over-rejections), 33 wrong orientations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the orientation residual is not a boundary tie (#84)

Stacked on the error-budget branch.

The residual's known shape - exactly one edge per wrong cell, always an
OVER-detection - has an obvious cheap explanation that turns out to be wrong,
and ruling it out eliminates a whole class of cause.

`crossesCliff` decides on the sign of `elevation - boundary`. If an endpoint sat
within float noise of a band boundary, the ~1e-6 our fields agree to would flip
the crossing, and the residual would be an irreducible precision limit with
nothing to fix.

Measured: every crossing edge in a wrong cell sits at least 0.205 from its
boundary, median ~9.9 - four to seven orders of magnitude clear of float noise.
For the game to disagree its elevation must differ from ours by more than 0.2,
which is a real field or rule difference.

Non-vacuity: the overall minimum across all 2,920 crossing edges is 6.4e-3,
thirty times tighter, so "far from the boundary" is a property of the wrong
cells and not of the sample.

Also pins WHICH channel the corner fixture holds, as a number rather than as
prose: against our per-tile elevation the worst corner differs by 4.8e-2;
against the grid-4 cliff channel the generator actually reads, by 96.09. #83 was
the most expensive mistake this subsystem has made and prose is not a guard.

The gap that leaves is the point: the grid-4 cliff-elevation channel has NO
per-corner oracle, is the only placement input never checked against the game
corner by corner, and after the margin result is the only remaining candidate
that could move an endpoint the required 0.2. Capturing it is the next step -
and not via calculate_tile_properties, which is the 1-tile program that produced
the wrong-channel fixture in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
…lusion (#84) (#107)

#106 concluded, from the band oracle, that the port's grid-4 cliff-elevation
field is wrong at [1500,1500]'s high bands, and handed over "it lives in the
mountains branch of vulcanus_elev". This sweep was run to quantify that. It
refutes it.

Sweeping `cliff_elevation_0` across [700,900] step 5 under the same
collapsed rule turns each placed cell's orientation into one-sided
constraints on its corners - a crossing at L says "this corner > L, that one
< L" - so 41 levels bracket a corner to the step. Only POSITIVE observations
are used, which is what makes it sound: an absent cliff is ambiguous (the
lava/ore rejections drop whole cells) but a present crossing is not, because
`fixImpossibleCellsSweep` only ever writes 0 and the rejections never touch
the edge registers.

- **996 of 998** two-sided brackets contain the port's value, mean bracket
  width **5.72**, in the worst region.
- The other two miss by **6.7e-4** - the port sits ON a swept level, where
  the strict test yields no observation. The open endpoint, not an error.
- At the disputed-edge corners: **26 of 26** bracketed ones contain it.

So the field is exonerated by direct measurement, and the lattice is
re-confirmed on the way: a wrong sampling site could not put 996 of 998
values inside 5-unit brackets.

**Where #106 went wrong.** It established what the residual is NOT (not
smoothing, not the gate, not the repair, not the rejections, not a boundary
tie) and treated the field as the last man standing - but it never measured
the game's field, only that OUR value sits a median 18.8 from the level.
"Everything else is excluded, so it must be X" is only as good as the list,
and the list was not closed. The lower bound was real; the attribution was
not. Same shape as #88.

What is left: across all 41 levels the game's code is the port's **minus
edges** in **1231 of 1235** disputed cells. The lead is the coverage number
- only 26 of 72 disputed corner slots get a bracket, and 661 of 1,659
corners in range get none, because the game emits nothing beside them. One
chunk at L=790 shows it directly: cell 1634,1706.5 keeps its TIGHT edge
(margin 5.9) and loses its WIDE one (margin 69), and the cell sharing that
wide edge is absent from the game entirely. That points at the
emission/rejection path - stated as a hypothesis, with the control named.

Determinism check: the two fixtures overlap at L=790 and agree 494/494.
Vacuity: shifting the field by 10 fails 3 of the spec's 5 tests.

Item of #84; the issue stays open.


Claude-Session: https://claude.ai/code/session_011DckiyMmb4zDPBV8NSyxTg

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
…scade (#84) (#110)

`vulcanusOreRejection.ts` left an explicit open question: the run remainders
are "a cascade along cliff connections OR a wider box". #108 makes the
cascade half concrete - a rejection zeroes the cell's edge registers, so a
neighbour's code, hence its ORIENTATION, hence its collision box, changes -
so re-testing to a fixpoint IS that cascade. `rejectionCascades` is the arm.

**Refuted.** Bit-for-bit identical at the shipping settings, and on the
collapsed rule it loses 14 matched cells and 7 orientations to gain 4 of the
over-placement (18654/693/1200/103 -> 18640/700/1196/110). The collapsed-rule
row is what makes the shipping no-op a result rather than an untriggered
branch. A rejected cell never turns a neighbour into a rejectable
orientation.

**The crossing STAGE explains 2 remainders for free.** The predicate fires on
20 placed cells; the placement loses 22, because zeroing a rejected cell's
edges leaves two neighbours with codes that no longer place. No tuning - it
falls out of #108's mechanism, and it is the first thing to reduce the
remainder count since the rule was characterised.

**And the rule now has a precision and a recall, not a total.**
`oracle-vulcanus-cliff-ore-direction` re-ran the region with resources off
through `autoplace_controls`, so the ore's effect is a known SET: 31 cells
suppressed, 0 appeared (the one-way property of #99, re-confirmed on the
entity region), 5 re-coded. Our model suppresses 22, **all 22 correct** -
**precision 1.000, recall 0.710**. Exactly right where it fires, simply too
narrow, which says which direction is safe to move in.

Attribution from the per-control arms: 27 calcite, 4 geyser, 0 tungsten/coal.
Of the 9 misses, 4 are geyser cells `includeGeyser: false` deliberately
excludes, 5 are calcite, and all 9 are adjacent to another suppressed cell.

**Half of [1500,1500]'s residual is not ore at all.** Running BOTH sides with
resources off: 876 matched, **13 wrong, 10 surplus**, 3 missing - against
842/16/19/3 with them on. Tuning the ore rule cannot reach that half, and it
is the larger target now.

What stays open is the wider-box half - the one #88 says must not be tuned
into fitting.

Item of #84; the issue stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExK3ASs5i2KMm9NwWqnZuj
wormeyman added a commit that referenced this pull request Aug 3, 2026
…169 (#84) (#111)

#110 asked for a lever that isolates the non-ore suppressor the way
`autoplace_controls` isolated the ore, with the suspect unnamed. This adds
two, through `map_gen_settings.autoplace_settings` - a knob
`autoplace_controls` cannot reach, since a control only touches prototypes
that name one and the rocks, chimneys and `crater-cliff` name none.

**No placed entity suppresses a Vulcanus cliff.** Switching the whole
`entity` category off removes 409 rocks, 115 chimneys, 45 rock explosions
and all 8 `crater-cliff`s, and the cliff set does not move by one cell.
Rocks were previously refuted only statistically against our own rock
model (#109); this is the class excluded positively, with the lever's own
proof in the same run three ways.

**Cliffs do not collide with each other**, refuted by the game's own
output rather than a model: 293 pairs of its own cliffs have overlapping
collision rectangles.

**Lava suppresses 169 cells and our rejection gets 166, with 5 false
positives** - precision 0.9708, recall 0.9822, the first real score that
rule has had. Its errors ARE the residual: the 3 it misses are 3 of the 10
surplus cells and the 5 it invents include all 3 of the port's missing
ones. They point both ways, so the box's shape is wrong rather than its
size - left measured and unfixed, per #88.

**With neither ore nor lava, recall is 1.0000** (1049 matched, 9 wrong, 12
surplus, 0 missing of 1058). The port produces a strict superset of the
game's cells, so everything left is over-placement and the question is now
what else the game refuses.


Claude-Session: https://claude.ai/code/session_015C8AbtvYMdc7ed3rnAZbhP

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
#84) (#115)

#114 reduced the residual to 31 destruction disagreements and handed over one
question: Surface::wouldCollide runs constCollideWithTile against the REAL
surface while the port resolves tiles from our own Vulcanus model, so a
disagreement between the two inside a cliff's box would produce exactly this
two-sided error set. It read as needing a new capture. It needed none.

The tile half is exonerated for all 6 false rejections.
oracle-vulcanus-lava-boundary is a committed 994-position dense capture of
surface.get_tile(x, y).name, taken for a different question back when the
collision box was the defect, and it covers every tile of all six boxes: 70 of
70 covered, ZERO disagreements, both directions. The game read the same lava we
do and placed the cliff anyway. Vacuity arms included, because "0 mismatches" is
also what a comparison that never ran would print.

A second candidate died in factorio-data rather than in a capture:
tiles-vulcanus.lua gives 17 tiles ground() and exactly 2 - lava and lava-hot -
lava(), so VULCANUS_CLIFF_BLOCKING_TILES is provably complete.

The 25 in the other direction do NOT share a cause. By Chebyshev distance from
each box to the nearest tile our own model calls lava, scored against the 1525
cells the port gets right so there is a base rate:

  within 2 tiles   missed 9 (36%)   matched 52 (3.4%)
  4 to 11 tiles    missed 6         matched 436
  none within 12   missed 10        matched 1037

The near group is enriched 10.5x - the signature of a boundary or a box a tile
short. But ten of the 25 have no lava within twelve tiles, so no adjustment to a
lava collision box can reach them; neither can the ore rule (all 25 are
ore=false) nor any entity (#111's lever moved zero cliffs). They also cluster
where the near group does not.

So the handover framing was too narrow: "which cells does wouldCollide reject
that ours does not" is the right question for at most 15 of the 25 and for none
of the 6. Fitting one box to all 31 would be fitting a rule to two causes at
once, which is the failure #88 exists to record.

Refuted on the way, and recorded so nobody re-derives it: cliff-versus-cliff
collision. Attractive because applyCliffs adds each cliff to the surface right
after testing it, and because #111's autoplace_settings lever cannot remove
cliffs - so this is the one case that lever never covered. It dies on the base
rate: 9 of the far 10 overlap another cliff's rotbb box, and so do 1405 of the
1531 cliffs the game KEEPS (91.8%). Independently, the cliff prototype's generic
collision_box is {{-0.99,-0.49},{0.99,0.49}} - "intentionally small" - and cliff
cells sit on a 4-tile grid, so generic boxes cannot overlap at all.

Nothing shipping changes; this is measurement only.


Claude-Session: https://claude.ai/code/session_01KVXnqZPGsD7A2SaPVA9ArA

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 4, 2026
…e destroy stage (#84) (#124)

The section before this put 11 of the 25 missed destructions on the ore rule
and named widening it as the next move. The mechanism was worth one more look
first, because `vulcanusOreRejection.ts` has always said the rule is
characterised rather than ported. It should NOT be widened.

Entity collision is excluded, now VERIFIED by three routes rather than asserted
from the call ordering alone:

1. Order - `computeInternal` calls `generateCliffs` at +0x2c, before it even
   builds the NoiseCache the three `generateEntities` passes use; `apply` calls
   `applyCliffs`, `applyDecoratives`, `applyEntities` in that order.
2. Inputs - `generateCliffs`' entire call list is `crossingsForChunk`,
   `MaybeCliffOrientation::value`, `tryToAddCliff`. No resource input at all.
3. Masks, at the PROTOTYPE level rather than the type default - calcite,
   tungsten-ore and sulfuric-acid-geyser are all `type = "resource"` and none
   overrides `collision_mask`, so all take `{layers={resource=true}}` against
   the cliff's `{item, meltable, object, player, water_tile, is_lower_object,
   is_object, cliff}`. Disjoint.

Route 3 kills the variant nobody had written down: CROSS-CHUNK ordering. Chunk
N's entities really are on the surface before chunk N+1's cliffs are applied,
so "the resource was already there" is available as an escape - and it still
cannot matter, at any box size, because the masks never intersect.

The one entity-versus-cliff test that exists runs the other way: `applyEntities`
calls `Surface::mapGeneratorWouldCollide` per queued entity and SKIPS the entity
on a hit. It never destroys a cliff.

The stage is measured: applying #122's discriminator to the lever's 31 cells,
exactly one is decidable and it says DESTROYED (`1546,1550.5`, a geyser cell,
neighbour `1546,1546.5` at `north-to-none`). So the effect enters at
`applyCliffs`/`Surface::wouldCollide`, not at `crossingsForChunk`. n=1 - the
spec says so, and carries the contrast arm showing the other 30 cost nothing.

Consequence: widening the box would fit a shape to an unexplained effect rather
than model a code path - exactly what #88 shipped and #90 had to undo. Close the
recall gap by finding what the resource control actually changes.

Also records `Surface::mapGeneratorWouldCollide` (0x101624a44), which was in
neither notes file and is distinct from both other wouldCollide overloads.

Measurement only - nothing shipping changed.


Claude-Session: https://claude.ai/code/session_015jeznBmWiZywUc7cBw9xFU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant